(高级篇)MySQL 并发控制—— MVCC 与锁

序言

  从前文我们知道,在事务运行过程中,数据库设置的隔离级别不同,解决的并发问题也不同。
  那么思考一个问题:这些隔离级别在内部到底是如何解决并发问题的呢?
  在数据库系统中,是通过 MVCC 和锁机制来解决该问题的。

  当多个事务同时操作同一份数据时,锁负责解决“谁可以操作、什么时候可以操作”的问题;而 MVCC 主要负责解决“读操作能不能不加锁、读到哪个版本”的问题。

  那么,下面来详细了解下它们吧!

MVCC

  MVCC(Multi-Version Concurrency Control),即多版本并发控制,其通过保存数据的多个版本,让事务能够读取适合自己的数据版本,从而实现非阻塞的并发读。

  MVCC 在 MySQL 的实现为在RC、RR两种隔离级别下,事务在执行普通的 SELECT 操作时通过 ReadView 快照访问 undolog 版本链查找当前可见数据的过程。

作用

  MVCC 用于解决一些并发问题,最终提高并发情况的性能:

  • 读-读冲突:在传统的锁机制下,多个事务可以同时读取同一行数据,但是如果有一个事务正在修改数据,则其他事务的读操作应该等待。MVCC 允许多个事务并发读取数据的不同版本,从而解决了读-读冲突的问题,提高了并发性能
  • 读-写冲突:在传统的锁机制下,读操作和写操作会发生冲突,即一个事务正在写入数据时,其他事务无法读取该数据。这会导致并发性能下降,MVCC 通过允许读操作与写操作并发进行,解决了读-写冲突的问题
  • 写-写冲突:传统的锁机制还会导致写操作之间的冲突,即多个事务同时试图修改同一行数据,只有一个事务能成功,其他事务需要等待。MVCC 通过使用版本号或时间戳来跟踪数据的不同版本,每个事务操作的是数据的一个特定版本,从而解决了写-写冲突的问题

  MVCC 支持一致性读取,即读取操作会看到一个一致性的数据库快照。读操作会根据事务开始的时间或快照版本确定可见的数据版本,而不会受到后续并发事务的修改影响。这样可以保证读操作的一致性,避免了脏读或不可重复读等问题。

数据库差异

  不仅是 MySQL,包括 Oracle、PostgreSQL 等其他数据库系统也都实现了 MVCC,但各自的实现机制不尽相同,因为 MVCC 没有一个统一的实现标准,典型的有乐观(optimistic)并发控制和悲观(pessimistic)并发控制。

原理

  使用版本链保存数据的多个版本。

版本链

  版本链是数据修改时出现的对应数据的一串 undo-log 链表。
  下面,我们通过一个例子了解它。

快速入门

  对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列:

  • trx_id:每次对某条聚簇索引记录进行改动时,都会把对应的事务 id 赋值给trx_id隐藏列。
  • roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,该隐藏列相当于一个指针,通过它可以找到该记录修改前的信息。

  下面的t表现在只包含一条记录:

1
2
3
4
5
6
7
mysql> SELECT * FROM t;
+----+--------+
| id | c |
+----+--------+
| 1 | 刘备 |
+----+--------+
1 row in set (0.01 sec)

  假设插入该记录时的trx_id(事务 id)为 80,那么此刻该条记录的示意图如下所示:

  假设之后两个trx_id分别为 100、200 的事务对这条记录进行 UPDATE 操作,操作流程如下表:

时间 trx_id:100 trx_id:200
T1 BEGIN;
T2 BEGIN;
T3 UPDATE t SET u = '关羽' WHERE id = 1
T4 UPDATE t SET u = '张飞' WHERE id = 1
T5 COMMIT;
T6 UPDATE t SET u = '赵云' WHERE id = 1
T7 UPDATE t SET u = '诸葛亮' WHERE id = 1
T8 COMMIT;

  每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性,将这些undo日志都连起来就形成了一个链表,所以现在的情况就像下图一样:

  版本链的头节点存储了当前记录的最新值,每次对该记录更新后,都会将旧值放到一条undo日志中,即该记录的一个旧版本。随着更新次数的增多,所有的版本都会被roll_pointer属性连接成一个链表,这个链表就被称之为版本链。

  另外,每个版本中还包含生成该版本时对应的trx_id,这个信息很重要,因为我们稍后就会用到。

ReadView

  对于不同隔离级别的事务来说,读取记录的方式也不同:

隔离级别 读取记录方式
READ UNCOMMITTED 直接读取记录的最新版本
READ COMMITTED 使用版本链
REPEATABLE READ 使用版本链
SERIALIZABLE 加锁后访问记录

  RC和RR都使用到了版本链,相同点是它们都需要借助ReadView判断版本链中的哪个版本为当前事务可见,不同点的是它们ReadView是否能重复生成。

  ReadView即读快照,其中主要存储了当前系统中还活跃的读写事务,这些活跃事务的trx_id将放到一个名为m_ids的列表中。
  因此,在访问某条记录时,只需要按以下步骤即可判断记录的某个版本是否可见:

  • 若被访问版本的trx_id属性值小于m_ids列表中最小的事务 id,表明生成该版本的事务在生成ReadView前已经提交,因此该版本可以被当前事务访问
  • 若被访问版本的trx_id属性值大于m_ids列表中最大的事务 id,表明生成该版本的事务在生成ReadView后才生成,因此该版本不可以被当前事务访问
  • 若被访问版本的trx_id属性值在m_ids列表中最大的事务 id 和最小事务 id 之间,那就需要判断一下trx_id属性值是不是在m_ids列表中:
    • 若存在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可被访问
    • 若不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问

  如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。
  如果最后一个版本也不可见的话,那么就意味着该条记录对该事务不可见,查询结果就不包含该记录。

  在MySQL中,READ COMMITTED和REPEATABLE READ隔离级别的的一个非常大的区别就是它们生成ReadView的时机不同,我们来看一下。

RC 场景

  在READ COMMITTED隔离级别下,每次读取数据前都将生成一个ReadView。
  我们先回忆一下,该隔离级别解决了什么并发问题。
  脏读,没错,该隔离级别不允许脏读。
  那么为何能解决脏读呢?
  因为未提交(被回滚)的事务操作涉及的数据记录对其他事务不可见。
  下面通过一个例子来解释一下:
  比方说现在系统里有两个trx_id分别为 100、200 的事务正在执行:

1
2
3
4
5
6
# trx_id 100
BEGIN;

UPDATE t SET c = '关羽' WHERE id = 1;

UPDATE t SET c = '张飞' WHERE id = 1;
1
2
3
4
5
# trx_id 200
BEGIN;

# 更新了一些别的表的记录
...

  此刻,t表中 id 为1 的记录得到的版本链表如下所示:

  假设现在有一个使用READ COMMITTED隔离级别的事务开始执行:

1
2
3
4
5
# 使用 READ COMMITTED 隔离级别的事务
BEGIN;

# SELECT 1:trx_id 为 100、200 未提交
SELECT * FROM t WHERE id = 1; # 得到的列 c 的值为'刘备'

  SELECT 1的执行过程如下:

  • 在执行SELECT语句时会先生成一个ReadView,ReadView的m_ids列表内容为[100, 200]
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列c的内容是'张飞',该版本的trx_id值为100,存在于m_ids列表,因此不符合可见性要求,根据roll_pointer跳到下一个版本
  • 下一个版本的列c的内容是'关羽',该版本的trx_id值也为100,也在m_ids列表内,因此也不符合要求,继续跳到下一个版本。
  • 下一个版本的列c的内容是'刘备',该版本的trx_id值为80,小于m_ids列表中最小的trx_id值100,因此这个版本是符合要求的,那么最后返回给用户的版本就是这条列c为'刘备'的记录

  这是事务 100 和 200 还未提交时的 SELECT情况,现在我们仅仅提交一下事务 100 :

1
2
3
4
5
6
7
8
# trx_id 100
BEGIN;

UPDATE t SET c = '关羽' WHERE id = 1;

UPDATE t SET c = '张飞' WHERE id = 1;

COMMIT;

  然后在trx_id为 200 的事务中更新一下t表中 id 为 1 的记录:

1
2
3
4
5
6
7
8
# trx_id 200
BEGIN;

# 更新了一些记录

UPDATE t SET c = '赵云' WHERE id = 1;

UPDATE t SET c = '诸葛亮' WHERE id = 1;

  此刻,t表中 id 为 1 记录的版本链就长这样:

  然后再到刚才使用READ COMMITTED隔离级别的事务中继续查找这个 id 为 1 的记录:

1
2
3
4
5
6
7
8
# 使用 READ COMMITTED 隔离级别的事务
BEGIN;

# SELECT 1:trx_id 100、200 均未提交
SELECT * FROM t WHERE id = 1; # 得到的列 c 的值为'刘备'

# SELECT 2:trx_id 100 提交,trx_id 200 未提交
SELECT * FROM t WHERE id = 1; # 得到的列 c 的值为'张飞'

  SELECT 2的执行过程如下:

  • 在执行SELECT语句时会先生成一个ReadView,ReadView的m_ids列表的内容就是[200](trx_id为100的那个事务已经提交了,所以生成快照时就没有它了)
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列c的内容是'诸葛亮',该版本的trx_id值为200,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本
  • 下一个版本的列c的内容是'赵云',该版本的trx_id值为200,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本
  • 下一个版本的列c的内容是'张飞',该版本的trx_id值为100,比m_ids列表中最小的trx_id值200还要小,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c为'张飞'的记录

  以此类推,如果之后trx_id为200的记录也提交了,再此在使用READ COMMITTED隔离级别的事务中查询表t中id值为1的记录时,得到的结果就是'诸葛亮'了,具体流程我们就不分析了。
  总结一下就是:使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView。

RR 场景

  在REPEATABLE READ隔离级别下,只会在第一次执行查询语句时生成一个ReadView,之后的查询不会重复生成。由于在同一个事务中,多次SELECT操作只会生成一个ReadView,所以自然就解决了不可重复读下的数据重复读取问题。

小结

  MVCC 只在RC、RR两个隔离级别下工作,其通过ReadView来判断事务版本信息,会读取版本链中的找到当前事务可见的数据版本。
  MVCC 可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。
  RC、RR两个隔离级别的一个很大不同就是生成ReadView的时机不同:

  • RC会在每一次进行普通SELECT操作前都生成一个ReadView
  • RR只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复该ReadView

  其他两个隔离级别都和 MVCC 不兼容,因为RU总是读取最新的数据行,而不是符合当前事务版本的数据行;SERIALIZABLE则会对所有读取的行都加锁。

扩展——同一事务插入数据二次查询可见

  思考一个问题,在 RR 隔离级别下,如果一种表存在 4 条数据,我们开启一个事务后执行以下操作:

  • ① 查询全表数据
  • ② 插入一条数据
  • ③ 查询全表数据

  那么,第 ③ 步会查询到几条数据?

  答案是 5 条数据。

  具体过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
mysql> BEGIN;
Query OK, 0 rows affected (0.01 sec)

mysql> SELECT * FROM test;
+----+------+
| a | b |
+----+------+
| 1 | 4 |
| 6 | 5 |
| 7 | 5 |
| 17 | 5 |
+----+------+
4 rows in set (0.00 sec)

mysql> INSERT INTO `osier`.`test` (`a`, `b`) VALUES (20, 5);
Query OK, 1 row affected (0.00 sec)

mysql> SELECT * FROM test;
+----+------+
| a | b |
+----+------+
| 1 | 4 |
| 6 | 5 |
| 7 | 5 |
| 17 | 5 |
| 20 | 5 |
+----+------+
5 rows in set (0.00 sec)

  为什么呢?

  这其实是对同一事务处理数据的不同导致的。

  在 MySQL 的 RR 隔离级别下,事务在读取数据时会生成一个一致性读视图 ReadView,ReadView 在事务开始时固定,因此再次读取相同的数据集时应看到相同的结果。然而,在上面描述的场景中,似乎在同一事务中插入了一条新记录,并在后续的 SELECT 语句中可见。

  这种现象并不违反 RR 隔离级别的规则,因为:

  • 自身修改的可见性:在 RR 隔离级别下,一个事务能够看见自己对数据的修改(包括插入、更新和删除)。虽然一致性读视图保证了在事务进行期间从其他并发事务中读取到的一致数据,但是事务自身的修改对自身是可见的。
  • 插入操作的例外:当在一个事务内插入一条新的记录时,这条记录会立即对该事务可见。这意味着虽然在第一次 SELECT 语句中获得了一个一致性快照,但是在同一个事务中插入的一条新记录对于该事务的后续查询是可见的

  具体到的 SQL 操作流程:

  • 开启事务,并执行第一次 SELECT 获得了初始一致性快照,此时显示 4 条记录
  • 执行 INSERT 操作插入一条新的记录(a 为 20,b 为 5)
  • 再次执行 SELECT 时,虽然 RR 隔离级别保证了其他事务对数据的修改对当前事务是不可见的,但这个 INSERT 操作确实是在当前事务中执行的,因此这条新插入的记录对于当前事务是可见的,所以此次查询返回了5条记录

  所以,RR 隔离级别中的一致性视图规则适用于其他事务的修改,但不适用于当前事务自身的修改。当前事务始终能够看到它自己所做的所有修改包括新增的数据。

InnoDB 中的读

  在 MySQL 中,支持三种类型的读语句:

  • 一致性(无锁)读
  • 半一致性读
  • 锁定读

不同类型的读

一致性(无锁)读

   一致性(无锁)读,亦称普通读,快照读,英文名:Consistent Read):指普通的SELECT读语句,即在SELECT语句末尾不加FOR UPDATE或者LOCK IN SHARE MODE的SELECT语句。

  普通读的执行方式是生成ReadView直接利用 MVCC 机制来进行读取数据,并不会对记录进行加锁。

注意事项

  注意哦,一致读取对某些 DDL 语句不起作用,比如说:

  • 对DROP TABLE操作不起作用,因为 MySQL 无法使用已被丢弃的表,而且 InnoDB 会销毁表
  • 对ALTER TABLE操作不起作用,因为 ALTER TABLE 操作会创建原始表的临时副本,并在创建临时副本时删除原始表。在事务中重新发出一致读时,新表中的行不可见,因为在事务快照时这些行还不存在。在这种情况下,事务会返回错误:ER_TABLE_DEF_CHANGED,”表定义已更改,请重试事务”

  对于 INSERT INTO … 等子句中的选择,读取类型有所不同。select、update …(SELECT) 和 CREATE TABLE …SELECT 等子句中的选择类型有所不同,这些子句没有指定 FOR UPDATE 或 LOCK IN SHARE MODE:

  • 默认情况下,InnoDB 会在这些语句中使用更强的锁,SELECT 部分的读取行为类似于 RC 下的 MVCC,在这种情况下,即使在同一个事务中,每次一致的读取都会设置并读取自己的新快照
  • 要在这种情况下执行无锁读取,请启用innodb_locks_unsafe_for_binlog选项,并将事务的隔离级别设置为 RU、RC 或 RR,以避免对从所选表读取的记录设置锁

半一致性读

  半一致性读(英文名:Semi-Consistent Read):这是一种夹在普通读和锁定读之间的一种读取方式。它只在READ COMMITTED隔离级别下(或者在开启了innodb_locks_unsafe_for_binlog系统变量的情况下)使用UPDATE语句时才会使用。具体的含义就是当UPDATE语句读取已经被其他事务加了锁的记录时,InnoDB会将该记录的最新提交的版本读出来,然后判断该版本是否与UPDATE语句中的WHERE条件相匹配,如果不匹配则不对该记录加锁,从而跳到下一条记录;如果匹配则再次读取该记录并对其进行加锁。这样子处理只是为了让UPDATE语句尽量少被别的语句阻塞。

  注意:半一致性读只适用于对聚簇索引记录加锁的情况,并不适用于对二级索引记录加锁的情况。

  另外,MySQL 官方文档并没有对半一致性读的单独说明,算是一致性读的一种特例。

锁定读

  锁定读(英文名:Locking Read):也叫当前读,指SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE这种读语句,在事务读取记录之前,必须先获取该记录对应的锁。当然,获取什么类型的锁取决于当前事务的隔离级别、语句的执行计划、查询条件等因素。

  特别提醒:UPDATE、DELETE 虽然不属于SELECT意义上的 Locking Read,但它们执行时需要读取要修改/删除的最新记录,因此属于当前读。

注意事项

  外层语句中的锁定读取子句不会锁定嵌套子查询中表的记录,除非在子查询中也指定了锁定读取子句。

  例如,以下语句不会锁定表 t2 中的记录。

1
SELECT * FROM t1 WHERE c1 = (SELECT c1 FROM t2) FOR UPDATE;

  因此,若要锁定表 t2 中的记录,请在子查询中也添加锁定读取子句:

1
SELECT * FROM t1 WHERE c1 = (SELECT c1 FROM t2 FOR UPDATE) FOR UPDATE;

读在不同隔离级别的转换

  对于SERIALIZABLE隔离级别来说,如果系统变量autocommit被设置为OFF,那普通读的语句会转变为锁定读,和在普通的SELECT语句后边加LOCK IN SHARE MODE达成的效果一样。

疑问:RR 隔离级别下真的能避免幻读嘛?

  我们在讨论问题之前,先回忆下幻读是什么。
  幻读指某个事务突然看到了一个它以前没有见过的数据行。比如说,若某个事务刚执行完一条SELECT语句就有另一个事务插入了一个新数据行,前一个事务再次执行同一条SELECT语句时,就可能多看到一个新的数据行,这就是幻读。

  在 RR 隔离级别下,一致性读看到是事务开始时的快照,即使其它事务插入了新行通常也是看不到的,所以在常见的场合可以避免幻读。 但是,对于执行锁定读、更新或者删除语句时,还是会看到数据库的最新状态,比如新插入的数据行,修改的数据行。

  下面通过一个例子来复现这种情况:

  第一步 SESSION 1 执行事务开启并通过快照读查询数据:

1
2
3
4
5
6
7
8
9
mysql> BEGIN;;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT id,c1 FROM tb1 where id=1;
+----+------+
| id | c1 |
+----+------+
| 1 | 100 |
+----+------+
1 row in set (0.00 sec)

  第二步 SESSION 2 执行更新操作(注意这里事务直接提交了,所以这里的 X 锁和后面的 S 锁不冲突):

1
2
3
mysql> UPDATE tb1 set c1=101 where id =1;
Query OK, 1 row affected (0.03 sec)
Rows matched: 1 Changed: 1 Warnings: 0

  第三步 SESSION 1 执行当前读、快照读、更新、快照读操作并提交事务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
mysql> SELECT id,c1 FROM tb1 WHERE id=1 LOCK IN SHARE MODE;
+----+------+
| id | c1 |
+----+------+
| 1 | 101 |
+----+------+
1 row in set (0.00 sec)

mysql> SELECT id,c1 FROM tb1 WHERE id=1;
+----+------+
| id | c1 |
+----+------+
| 1 | 100 |
+----+------+
1 row in set (0.00 sec)

mysql> UPDATE tb1 set c1=c1+1000 WHERE id=1;
Query OK, 1 row affected (0.02 sec)
Rows matched: 1 Changed: 1 Warnings: 0

mysql> SELECT id,c1 FROM tb1 WHERE id=1;
+----+------+
| id | c1 |
+----+------+
| 1 | 1101 |
+----+------+
1 row in set (0.00 sec)

  上面 UPDATE 的行为违反了 RR 的承诺,看到了事务开始后其它事务的并发更新。这对应用开发需要特别注意,这种情况下其它数据库通常都是报错的。

  扯了这么多,我们如何避免遇到这个并发更新问题呢?

  答案很简单,修改时加WHERE条件。

InnoDB 中的锁

  在 MySQL InnoDB 存储引擎中,锁的类别非常多:

  • 全局锁\表锁\行锁
  • 共享Shared锁和排他Exclusive锁
  • 意向Intention锁
  • 记录Record锁
  • 间隙Gap锁
  • 临键Next-Key锁
  • 插入意向Insert Intention锁
  • 自增AUTO-INC锁

  初看容易让人头晕眼花,其实对于上述这些锁而言,其实并不完全是同一个纬度的东西,因此我们可以按纬度进行细分:

  • 锁的粒度:锁多大?
    • 行锁的范围:行锁具体会锁什么?
  • 锁的模式:以什么权限锁?写还是读?
  • 锁的协调:表级锁和行级锁之间怎么协调?间隙行插入数据如何协调不同间隙?

不同纬度下的锁

全局锁\表锁\行锁

  从锁的粒度维度,可以分成全局锁、表锁、行锁三大类:

类别 锁定粒度 典型特点 并发能力
全局锁 整个 MySQL 实例 锁定范围最大,主要用于全库备份 最低
表锁 整张表 锁定范围较大,锁管理成本较低 较低
行锁 索引记录或索引范围 锁定范围较小,锁管理成本较高 较高

  当然,不同的存储引擎支持的锁粒度不同:

  • MyISAM 只支持表锁
  • InnoDB 既支持行锁又支持表锁

全局锁

  顾名思义,全局锁就是对整个数据库实例加锁。

  MySQL 提供了一个加全局读锁的方法,命令是:

1
FLUSH TABLES WITH READ LOCK;

  全局锁的典型使用场景是,做全库逻辑备份。因此,当需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:

  • 数据更新语句(数据的增删改)
  • 数据定义语句(包括建表、修改表结构等)
  • 更新类事务的提交语句。

表锁

  MySQL 里面表级别的锁有两种:一种是显式的表锁,一种是隐式的元数据锁(meta data lock,MDL)。

显式表锁

  如果要显式的对表进行加锁,相关语法为lock tables … read/write。可以用unlock tables主动释放锁,也可以在客户端断开的时候自动释放。需要注意,lock tables语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。

  举个例子, 如果在某个线程 A 中执行lock tables t1 read, t2 write;这个语句,则其他线程写 t1、读写 t2 的语句都会被阻塞。同时,线程 A 在执行 unlock tables 之前,也只能执行读 t1、读写 t2 的操作。连写 t1 都不允许,自然也不能访问其他表。

隐式的 MDL 锁

  MDL 锁,即元数据锁(meta data lock),MDL 锁不需要显式使用,在访问一个表的时候会被自动加上。

  MDL 锁的作用是保证读写的正确性。你可以想象一下,如果一个SELECT查询正在遍历一个表中的数据,而SELECT执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。

  因此,在 MySQL 5.5 版本中引入了 MDL:

  • 当对一个表做增删改查操作的时候,加 MDL 读锁
  • 当要对表做结构变更操作的时候,加 MDL 写锁

  其中:

  • 读锁与读锁之间不互斥,因此可以有多个线程同时对一张表增删改查
  • 读写锁之间、写锁之间是互斥的。这是用来保证变更表结构操作的安全性,因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行
生产影响:表结构修改导致请求变慢

  线上执行 DDL 时,要重点排查长事务、长查询和 MDL 等待,否则一次看似普通的表结构变更,就可能造成业务请求大量阻塞。

  举个例子,加入线上有一个线程 A 查询 SQL SELECT * FROM orders WHERE user_id = 100;,其执行时间比较长,此时 线程 A 会持有 MDL 读锁。

  如果 DBA 此时线程 B 执行ALTER TABLE orders ADD COLUMN remark VARCHAR(255);,线程 B 需要获取 MDL 写锁,由于 MDL 读锁写锁互斥,所以 DDL 必须等待前面的查询结束。

  因此,如果SELECT语句很慢,线程 A 一直持有 MDL 读锁,线程 B 申请 MDL 写锁,一直排队等待 MDL 写锁,后续又来了线程 C、D、E、F,通过SELECT * FROM orders;查询订单业务数据 ,那么将全部被阻塞。

  于是原本只是一个 DDL 等一个长查询,可能进一步演变成:一个长查询 → 阻塞 DDL → DDL 又导致后续大量请求排队。

  这也是为什么线上经常会出现一种很典型的事故:“我只是执行了一个 ALTER TABLE,为什么整个数据库突然大量请求卡住了?”

最佳实践

  从前可知:线上执行 DDL,一定要特别谨慎。有时虽然只是增加一个字段,看起来很简单,但它并不是:“执行一下就完事了。

  而是需要考虑当前执行的时间点:

  • 有没有长事务?
  • 有没有长查询?
  • 有没有人在访问这张表?
  • DDL 是否需要等待 MDL?
  • 等待期间会不会阻塞新的请求?
  • DDL 执行本身需要多久?
  • 是否会影响线上流量?

  所以生产环境一般不会随意直接ALTER TABLE xxx ...,而是会根据表大小、业务流量、DDL 类型等情况选择合适的方案,例如:

  • 业务低峰期执行
  • 提前检查长事务
  • 使用在线 DDL(MySQL 内部会根据不同修改评估)
  • 使用 gh-ost
  • 使用 pt-online-schema-change
  • 大表结构变更进行专门的变更评估

  一般情况下,选择在业务低峰期执行表结构修改 SQL 即可。

AUTO-INC 锁(了解)

  AUTO-INC 锁是一个特殊的表级锁,由插入有 AUTO_INCREMENT 列的表的事务使用。在最简单的情况下,如果一个事务正在向表中插入数值,任何其他事务必须等待向该表进行自己的插入,这样第一个事务插入的行就会得到连续的主键值。

  innodb_autoinc_lock_mode变量控制用于自动增量锁定的算法。它允许你选择如何在可预测的自动递增值序列和插入操作的最大并发性之间进行交换。

行锁

  MySQL 中的行锁主要是 Record Lock、Gap Lock、Next-Key Lock。

Record 锁(记录锁)

  记录锁是对一个索引记录的锁,在使用精准匹配处理数据时会使用到。

  例如,SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE;语句可以防止任何其他事务插入、更新或删除t.c1的值为10的记录。

  记录锁总是锁定聚集索引的对应记录,即使一个表被定义为没有索引。在这种情况下,InnoDB 会创建一个隐藏的聚集索引,并使用这个索引来锁定记录。

  记录锁的事务数据在SHOW ENGINE INNODB STATUS和 InnoDB 监控输出中显示类似于以下内容:

1
2
3
4
5
6
RECORD LOCKS space id 58 page no 3 n bits 72 index `PRIMARY` of table `test`.`t`
trx id 10078 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 8000000a; asc ;;
1: len 6; hex 00000000274f; asc 'O;;
2: len 7; hex b60000019d0110; asc ;;
Gap 锁(间隙锁)

  间隙锁是对索引记录之间的间隙的锁,或者对第一个或最后一个索引记录之前的间隙的锁。

  例如,SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE;语句可以防止其他事务在列t.c1中插入一个15的值,无论该列中是否已经有这样的值,因为该范围中所有现有值之间的空隙被锁定。

  一个间隙可能横跨一个索引值,多个索引值,甚至是空的。

  间隙锁是性能和并发性之间权衡的一部分,并且只在一些事务隔离级别中使用。

  对于使用唯一索引锁定行以搜索唯一行的语句,不需要间隙锁。(这不包括搜索条件只包括多列的某些列的情况。

  在 locking reads (SELECT with FOR UPDATE or LOCK IN SHARE MODE),UPDATE 和 DELETE 时,锁定取决于 SQL 语句是否是使用具有唯一搜索条件的唯一索引,或者范围类型的搜索条件:

  • 对于具有唯一搜索条件的唯一索引,InnoDB 只锁定找到的索引记录,不锁定之前的间隙
  • 对于其他搜索条件,InnoDB 锁定扫描的索引范围,使用间隙锁或临键锁来阻止插入由其他 Session 填补范围内覆盖的间隙
Next-Key 锁(临键锁)

  next-key lock 是索引记录上的记录锁和索引记录之前的间隙上的间隙锁的组合。

InnoDB以这样的方式执行行级锁定:当它搜索或扫描表索引时,它会在遇到的索引记录上设置共享或排他锁。因此,行级锁实际上是索引记录锁。索引记录上的下一个键锁也会影响该索引记录之前的“间隙”。也就是说,下一个键锁是索引记录锁加上索引记录前面间隙上的间隙锁。如果一个会话对索引中的记录具有共享或排他锁 ,则另一个会话不能在索引顺序中R`紧接在之前的间隙中插入新的索引记录

  假设一个索引包含值 10、11、13 和 20。该索引的可能的 next-key 锁涵盖以下区间,其中圆括号表示排除区间端点,方括号表示包含端点:

1
2
3
4
5
(负无穷, 10]
(10, 11]
(11, 13]
(13, 20]
(20, 正无穷)

  对于最后一个间隔,next-key 锁会锁定索引中最大值上方的间隙以及“ supremum ”(正无穷)为记录,该记录的值高于索引中实际的任何值。 supremum 不是真正的索引记录,因此,实际上,此 next-key 锁仅锁定最大索引值后面的间隙。

  SHOW ENGINE INNODB STATUSNext-Key 键锁的事务数据将输出类似于以下内容 :

1
2
3
4
5
6
7
8
9
RECORD LOCKS space id 58 page no 3 n bits 72 index `PRIMARY` of table `test`.`t`
trx id 10080 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;

Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 8000000a; asc ;;
1: len 6; hex 00000000274f; asc 'O;;
2: len 7; hex b60000019d0110; asc ;;

共享锁和排他锁

  InnoDB 实现了标准的行级锁,有两种类型的锁,共享(S)锁和排他(X)锁。

  • 共享(S)锁:允许持有该锁的事务读取一条记录,多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改;
  • 排他(X)锁:允许持有该锁的事务更新或删除一条记录,排他锁不能与其他锁并存,如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的锁(共享锁、排他锁),只有该获取了排他锁的事务是可以对数据行进行读取和修改

  如果事务 T1 持有 r 行的共享(S)锁,那么来自不同事务 T2 对 r 行的锁的请求将被如下处理:

  • T2 对 S 锁的请求可以被立即批准。结果是,T1 和 T2 都持有 r 的 S 锁
  • T2 对 X 锁的请求不能被立即批准

  如果一个事务 T1 在行 r 上持有一个排他(X)锁,那么某个不同的事务 T2 对 r 的任一类型的锁的请求不能立即被批准。相反,事务 T2 必须等待事务 T1 释放其对 r 行的锁。

  在持有一种类型的 S 锁或 X 锁后,是否可以获取另一把锁,简单示例如下图:

请求 S 锁 X 锁
S 锁 ✅ ❌
X 锁 ❌ ❌

加锁示例

S 锁加锁释锁示例

  加锁方式:SELECT * FROM STUDENT where id = 1 LOCKINSHARE MODE;

  释放锁:commit/rollback;

X 锁加锁释锁示例
  • 自动:INSERT、DELETE、UPDATE默认加上 X 锁;
  • 手动:SELECT * FROM STUDENT WHERE id = 1 FOR UPDATE;

Q:需要关注 S 锁嘛?

  一般关注 X 锁即可,因为 S 锁基本不会显式用到,S 锁只有在Serializable隔离级别下,所有的SELECT语句会被隐式的转化为SELECT ...LOCK IN SHARE MODES,此时与UPDATE、DELETE相关 SQL 互斥。

意向锁

  InnoDB支持多粒度锁,允许行锁和表锁共存。例如,一个诸如LOCK TABLES ...WRITE这样的语句在指定的表上取得了一个排他锁(一个X锁)。为了使多个粒度级别的锁切实可行,InnoDB 使用意向锁。

类型

表级意向锁:IS、IX 锁

  我们通常讨论的意向锁,是表级别的锁,它表明事务以后需要对表中的某一行进行哪种类型的锁(共享或排他)。

  针对 MySQL 的表,存在两种类型的意向锁:

  • 意向共享锁(IS):表示事务打算对表中的个别行设置一个共享锁
  • 意向排他锁(IX):表示一个事务打算在表中的个别行上设置一个排他锁

  例如,SELECT ... LOCK IN SHARE MODE设置了一个 IS 锁,SELECT ... FOR UPDATE设置了一个 IX 锁。

  意向锁协议如下:

  • 在一个事务可以获得表内某行的共享锁之前,它必须首先获得该表的 IS 锁或更强的锁
  • 在一个事务可以获得表中某行的排他锁之前,它必须首先获得该表的 IX 锁

  表级的锁类型兼容性总结在下面的矩阵中。

X IX S IS
X 冲突 冲突 冲突 冲突
IX 冲突 兼容 冲突 兼容
S 冲突 冲突 兼容 兼容
IS 冲突 兼容 兼容 兼容

  如果一个锁与现有的锁兼容,就会授予请求的事务,但如果与现有的锁冲突,就不会授予。一个事务会等待,直到与之冲突的现有锁被释放。如果一个锁请求与现有的锁冲突,并且不能被授予,因为它会导致死锁,那么就会发生一个错误。

  除了全表请求(例如,LOCK TABLES … WRITE),意向锁不会阻止任何东西。意向锁的主要目的是显示有人正在锁定某一行,或将要锁定表中的某一行。

  在SHOW ENGINE INNODB STATUS和InnoDB监控输出中,意向锁的事务数据显示类似于以下内容:

1
TABLE LOCK table `test`.`t` trx id 10080 lock mode IX
行级意向锁:插入意向锁

  意向锁除了会在表级表,还有行级表的意向锁,比如插入意向锁

  插入意向锁是一种由 INSERT 操作在行插入前设置的间隙锁。这种锁发出了插入意向的信号,如果多个事务在同一索引间隙中插入的位置不一样,就不需要互相等待。

  假设有数值为 4 和 7 的索引记录。分别试图插入值为 5 和 6 的事务,在获得插入行的排他锁之前,各自用插入意向锁(间隙锁)锁定了 4 和 7 之间的间隙,但是由于这些行是不冲突的,所以不会互相阻塞。

观察插入意向锁

  下面的例子演示了一个事务在获得被插入记录的排他锁之前,采取插入意向锁。这个例子涉及两个客户,A 和 B。

  客户端 A 创建了一个包含两个索引记录(90 和 102)的表,然后启动一个事务,对 ID 大于 100 的索引记录加了一个排他锁。该排他锁包括 102 号记录前的间隙锁:

1
2
3
4
5
6
7
8
9
10
mysql> CREATE TABLE child (id int(11) NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;
mysql> INSERT INTO child (id) values (90),(102);

mysql> START TRANSACTION;
mysql> SELECT * FROM child WHERE id > 100 FOR UPDATE;
+-----+
| id |
+-----+
| 102 |
+-----+

  客户端 B 开始一个事务,向缺口插入一条记录。该事务在等待获得排他锁的同时,采取了一个插入意向锁。

1
2
mysql> START TRANSACTION;
mysql> INSERT INTO child (id) VALUES (101);

  在SHOW ENGINE INNODB STATUS和 InnoDB 监控输出中,插入意向锁的事务数据显示类似于以下内容:

1
2
3
4
5
6
RECORD LOCKS space id 31 page no 3 n bits 72 index `PRIMARY` of table `test`.`child`
trx id 8731 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 80000066; asc f;;
1: len 6; hex 000000002215; asc " ;;
2: len 7; hex 9000000172011c; asc r ;;...

Q:为什么有意向锁?

  意向锁存在原因主要在两点:

  • 提高并发性能
  • 防止锁冲突和死锁
提高并发性能

  意向锁可以提高并发操作的性能,啥意思呢?我们可以先讨论个问题。
  如果一个数据行已被一个线程加锁处理中了,此时再来了一个新线程,那么新线程正常情况下会怎么执行自己的后续步骤呢?
  答案很简单,尝试对数据行加锁,但是会发现数据行已被加锁,那么阻塞等待。

  那么,经过以上一个过程,我们可以再思考一个问题:如果发现一个数据行已被加锁,后续线程可不可以直接阻塞等待,不要去尝试对数据行加锁,反正也抢不到锁,这么做可以减少一个步骤。
  当然,可以,那么如何做呢?
  需要一个标志来标明数据行已被加锁,而意向锁就是标志来确定是否存在的。

  打个比方,我们在节假日聚会时,会提前预定好餐厅的包间,如果没预定到,那么就不去现场了,预定包间 –> 去预定的包间现场是两个顺序节点的时间事件,如果没预定到包间,那么就不用去现场了,不然是浪费时间。

防止锁冲突和死锁

  在没有意向锁的情况下,行锁和表锁之间可能会产生冲突,多个事务插入间隙数据时也可能产生冲突。例如,一个事务在表上加了锁,而另一个事务试图在表中的某些行上加锁,这样会导致死锁或阻塞。

  而意向锁可以通过在表级和行级之间设置一种明确的锁定意图,有效地避免这种情况。它让数据库知道某个事务打算在某个级别上获取锁,避免了重复的锁检查和潜在的死锁。

MySQL 锁处理机制

  MySQL InnoDB 存储引擎通过一套自动检测与回滚机制来处理死锁。当检测到死锁时,它会主动选择一个“代价最小”的事务进行回滚,以打破循环等待,让其他事务得以继续执行。

主动检测与超时机制

  MySQL 主要通过两种方式应对死锁:

  • 主动检测:当事务发生锁等待时,InnoDB 会检查事务之间有没有形成“互相等待”的环,如果发现有环,就说明发生了死锁,会立即回滚一个事务打破死锁
    • 选择性牺牲:为了让回滚代价最小化,当确认发生死锁后,InnoDB 需要从循环等待的事务中挑选一个“牺牲者”(Victim)进行回滚。选择的核心原则是回滚代价最小,以尽可能减少对系统的影响。主要依据以下标准:
      • 修改的行数(Undo 量):优先选择已修改行数最少的事务,因为回滚操作需要撤销这些修改,代价相对更低
      • 事务权重:InnoDB 会为每个事务计算一个权重值,该权重综合了事务已持有的锁数量、修改的数据量等因素。权重越低的事务,越有可能被选为牺牲者
      • 事务优先级:如果用户通过某些方式为事务设置了优先级,InnoDB 也会参考此信息,倾向于回滚优先级较低的事务
    • 等待图(Wait-for Graph)算法:InnoDB 会实时构建一个“等待图”,图中的节点是事务,边表示一个事务正在等待另一个事务释放锁。当有加锁请求需要等待时,系统就会触发检测,通过深度优先搜索(DFS)遍历该图。一旦在图中发现环路,就意味着发生了死锁
  • 被动兜底:锁等待超时:这是当主动检测被禁用或未能及时检测到死锁时的保底方案。如果一个事务等待锁的时间超过了 innodb_lock_wait_timeout 参数设定的阈值(默认 50 秒),该事务将被自动回滚,从而释放锁。

  两者差异如下:

维度 主动死锁检测 锁等待超时
判断依据 等待图是否有环 等待时间是否超阈值
触发时机 加锁需要等待时检查 等待达到 innodb_lock_wait_timeout
处理对象 死锁环中的某个牺牲者 当前等待超时的事务/语句
是否处理常规锁等待 不处理,无环就继续等 会处理,等太久则报错
作用 专用解决 InnoDB 死锁 兜底防止无限等待,普通锁等待也可能触发它
速度 通常很快,毫秒级 默认最多 50 秒
错误 1213 Deadlock 1205 Lock wait timeout
默认状态 innodb_deadlock_detect=ON innodb_lock_wait_timeout=50

核心配置与参数

  可以通过以下系统变量来控制死锁的处理行为:

参数 默认值 说明
innodb_deadlock_detect ON 是否启用主动死锁检测。禁用后,将完全依赖超时机制来处理死锁,可能在特定高并发场景下提升性能,但会增加死锁的存活时间
innodb_lock_wait_timeout 50 (秒) 事务等待锁的超时时间。当主动检测被禁用时,这是唯一的解决手段
innodb_print_all_deadlocks OFF 是否将所有死锁信息打印到 MySQL 错误日志中。默认只保留最近一次的死锁信息(通过 SHOW ENGINE INNODB STATUS 查看)
在生产环境排查问题时,建议开启此参数

日志分析与诊断

  当死锁发生后,可以通过以下方式获取详细信息进行排查:

  • 查看最近一次死锁:执行 SHOW ENGINE INNODB STATUS\G,在输出结果的 LATEST DETECTED DEADLOCK 部分,可以看到最近一次死锁的详细日志,包括涉及的事务、持有的锁、等待的锁以及最终回滚的事务。
  • 记录所有死锁:如果死锁频繁发生,建议设置 innodb_print_all_deadlocks = ON,这样所有死锁事件都会被记录到 MySQL 的错误日志中,便于分析历史模式和定位根本原因。

处理策略与预防建议

  死锁在事务型数据库中难以完全杜绝,正确的处理策略至关重要。

  • 应用层必须实现重试机制:当应用程序收到死锁错误(错误码 1213)时,应当捕获该异常并自动重试整个事务。
  • 预防优于处理:通过良好的设计可以显著降低死锁概率:
    • 统一访问顺序:在多个事务中,以相同的顺序访问表和行。例如,都按主键 ID 升序更新,能有效避免循环等待
    • 保持事务短小:避免长事务,尽快提交,减少锁的持有时间
    • 合理使用索引:确保 UPDATE 和 SELECT ... FOR UPDATE 语句的 WHERE 条件能有效利用索引,减少锁定的行数
    • 考虑降低隔离级别:在业务允许的情况下,使用 RC 隔离级别,可以减少间隙锁(Gap Lock)的使用,从而降低死锁风险

小结

  MySQL 通过主动检测和被动超时回滚双机制来管理死锁,并依据回滚代价最小化原则选择牺牲事务。对开发者而言,关键在于理解其机制,在应用层做好重试逻辑,并通过统一访问顺序、保持事务短小等最佳实践来从源头预防死锁。

MVCC VS 锁

  MVCC 解决的是“读的问题”,通过多版本让普通读尽量不阻塞写;锁解决的是“并发修改的问题”,通过互斥保证多个事务不会同时破坏同一份数据。

对比维度 MVCC 锁
核心解决的问题 读数据时尽量不阻塞其他事务 并发修改/访问同一数据时保证正确性
主要面对 普通 SELECT UPDATE、DELETE、锁定读等
核心思想 保存数据的多个版本,让读者读取自己可见的版本 控制多个事务对同一数据的访问权限
普通 SELECT 通过 ReadView + Undo Log 读取可见版本 通常不需要加行锁
UPDATE / DELETE 不是主要解决手段 通过 X 锁等控制并发修改
SELECT ... FOR UPDATE 不属于普通快照读 当前读,需要加锁
是否阻塞写 通常不会 可能会
是否阻塞读 普通快照读通常不会 锁定读可能产生等待
典型业务价值 提高读并发 保证写并发下的数据正确性
典型场景 读:商品浏览、订单查询、报表查询 写:扣库存、修改订单状态、账户余额更新
核心关键词 版本、快照、可见性 互斥、冲突、等待

  在实际的业务场景中,基本都是读多写少的,因此普通查询通过 MVCC 提高并发;真正存在业务竞争的数据,才通过锁进行协调,两者协同作用,最大化提供系统并发性能。

锁相关表及重要字段说明

  MySQL 中查看锁信息时,经常出现的那些表/字段,主要集中在 performance_schema 和 information_schema 中。

  实际排查死锁时,最重要的是下面这几类:

表 主要用途
performance_schema.data_locks 当前有哪些锁
performance_schema.data_lock_waits 谁在等谁的锁
performance_schema.threads 找到线程信息
information_schema.innodb_trx 当前事务信息
information_schema.innodb_lock_waits 老版本查看锁等待关系
information_schema.innodb_locks 老版本查看具体锁

  其中,新版本 MySQL 主要看 performance_schema.data_locks + data_lock_waits。

performance_schema.data_locks

  从这张表可以知道:现在到底有哪些锁?谁持有的?锁在哪张表、哪个索引、哪个记录上?

  常见字段:

字段 含义
ENGINE 存储引擎,例如 INNODB
ENGINE_LOCK_ID 锁的唯一标识
ENGINE_TRANSACTION_ID 持有这个锁的事务 ID
THREAD_ID 对应 MySQL 线程
EVENT_ID 对应事件 ID
OBJECT_SCHEMA 数据库名
OBJECT_NAME 表名
INDEX_NAME 索引名称
OBJECT_INSTANCE_BEGIN 锁对象内部地址
LOCK_TYPE 锁的对象类型,如 TABLE、RECORD
LOCK_MODE 锁模式,非常重要
LOCK_STATUS GRANTED / WAITING
LOCK_DATA 锁对应的数据

LOCK_TYPE

  LOCK_TYPE是最值得记的字段,含义如下:

  • TABLE:表级锁
  • RECORD:行锁,注意这里的 RECORD 不等于“只锁一行”。因为 InnoDB 的 Record Lock、Gap Lock、Next-Key Lock 都属于这里的 RECORD 类型,所以这里RECORD翻译为记录级锁不合适

LOCK_MODE

  LOCK_MODE,可能取值为下表说明:

LOCK_MODE 含义
S 共享锁
X 排他锁
S,GAP 共享间隙锁
X,GAP 排他间隙锁
X,REC_NOT_GAP 排他记录锁,不包含 Gap
X,GAP,INSERT_INTENTION 插入意向锁

LOCK_STATUS

  LOCK_STATUS取值只有两个特别重要GRANTED、WAITING,例如:

1
2
事务A:X    GRANTED
事务B:X WAITING

  代表 A 已经拿到锁,B 正在等待 A。

INDEX_NAME

  INDEX_NAME字段对分析死锁非常重要。例如:

1
2
PRIMARY
idx_ac_id_cache_no

  它告诉你:锁到底落在了哪个索引上,这非常重要,因为 InnoDB 的锁本质上是锁索引记录。

比如:

1
2
3
UPDATE coupon
SET cache_no = '2025a2'
WHERE ac_id = '99d1d6fff0a';

  即使你感觉是在“锁这条数据”,实际上 InnoDB 可能是在idx_ac_id_cache_no这个二级索引上进行扫描和加锁。

5. LOCK_DATA

  这个字段非常适合实际排查,例如可能看到99d1d6fff0a或者:10, 'abc',它表示:当前锁对应的索引记录/索引键值。

因此排查时经常形成这样一条链:

1
2
3
4
5
6
7
8
9
哪张表
↓
哪个索引
↓
哪个索引记录
↓
谁持有锁
↓
谁在等待

performance_schema.data_lock_waits

这个表作用如其表明:数据在等待谁?,它主要解决“锁关系”问题。

核心字段:

字段 含义
REQUESTING_ENGINE_LOCK_ID 正在请求锁的锁 ID
BLOCKING_ENGINE_LOCK_ID 阻塞它的锁 ID

实际上描述:事务B → 等待 → 事务A

例如:

1
2
REQUESTING_ENGINE_LOCK_ID = B
BLOCKING_ENGINE_LOCK_ID = A

可以理解成:B 被 A 阻塞,这张表通常需要和 data_locks 联合起来看。

封锁粒度与封锁协议

  数据库中为了实现并发控制而采用封锁技术。封锁对象的大小称为封锁粒度( Granularity )。

  封锁的对象可以是逻辑单元,亦可以是物理单元。
  以关系数据库为例,封锁对象可以为以下逻辑单元:

  • 属性值
  • 属性值的集合
  • 元组
  • 关系
  • 索引项
  • 整个索引项
  • 甚至整个数据库

  封锁对象亦可以为以下物理单元:

  • 页(数据页或索引页)
  • 物理记录等

  在运用 X 锁和 S 锁对数据对象加锁时,还需要约定一些规则。比如何时申请 X 锁 或 S 锁、持锁时间多久、何时释放等规则,这些规则被称为为封锁协议( Locking Protocol )。
  对封锁方式规定的不同规则,形成了不同的封锁协议,而不同的封锁协议又对应不同的隔离级别。

两阶段锁协议

  在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。

疑问

MySQL 读写能否并行?

  在并发处理数据时,核心问题是读与写之间的冲突。如果读写都加锁,程序并发性能会明显下降;如果能做到读写分离——读时允许写、写时允许读、读读之间不互斥,则并发性能就会更高。

  那么,在我们使用 MySQL 时,就会有疑问了:

  • 读写是否允许并行: MySQL 在读取数据时,是否会直接通过 MVCC 来读取该数据的一个特定版本,从而使读操作与写同一数据的操作不冲突,即此时另一事务写相同数据时可直接写?
  • 写读是否允许并行:当一个事务对某行数据执行 UPDATE 但尚未提交时,另一个事务是否可以通过 MVCC 直接 SELECT 这行数据,而不需要等待写事务完成?

  答案是:如果上面的读指的是普通的 SELECT 读,那么你的理解是对的,读写/写读可以并行。

  MVCC 主要解决的是“普通读”和“写”之间的冲突,让普通读不必等待写;但它并不是说“所有读写操作都互不冲突”,因为 MySQL InnoDB 实际上是把问题拆成了两套机制:MVCC 负责普通读,锁负责写以及需要强一致性的读。

  我们可以先按照下面的图来整体认识:

操作 是否主要依赖 MVCC 是否可能加锁 是否阻塞写操作
普通 SELECT ✅ 是 通常不加行锁 通常不阻塞写
UPDATE ❌ ✅ X 锁 会和其他写冲突
DELETE ❌ ✅ X 锁 会和其他写冲突
SELECT ... FOR UPDATE ❌ ✅ X 锁 可能等待写
SELECT ... LOCK IN SHARE MODE ❌ ✅ S 锁 可能等待写

  下面结合上面的两个问题具体研究下。

读写允许并行

  假设现在有一个订单:订单ID = 1001,订单状态 = 待支付,数据库里面字段:

1
2
id    status
1001 待支付

  现在出现两个事务。

  如果事务 A:

1
2
3
4
5
6
BEGIN;

SELECT *
FROM order
WHERE id = 1001;
-- 暂时不 COMMIT

  与此同时,事务 B:修改订单,但是事务 A 还没有提交。

1
2
3
4
5
6
7
BEGIN;

UPDATE order
SET status = '已支付'
WHERE id = 1001;

-- 暂时不 COMMIT

  那么,事务 B 执行时是否会阻塞?

  答案是不会阻塞,因为事务 A 读取数据时执行时并没有加锁,所以不影响事务 B。

写读允许并行

  还是前面的数据,现在出现两个事务。

  事务 A 执行不提交的写 SQL:

1
2
3
4
5
6
7
BEGIN;

UPDATE order
SET status = '已支付'
WHERE id = 1001;

-- 不提交

  然后事务 B 执行读 SQL:

1
2
3
4
5
BEGIN;

SELECT *
FROM order
WHERE id = 1001;

  会不会被事务 A 的 UPDATE 卡住?

  答案是:不会,事务 B 的普通 SELECT 直接返回旧的:

1
2
id    status
1001 待支付

  而不是事务 A 修改的已支付,因为事务 B 查询时事务 A 没有提交。

  因此:写的时候,普通读可以直接读,不会被阻塞等待。

  怎么做到的呢?

  事务 B 不是读取事务 A 尚未提交的数据,它读取的是这个数据在当前事务可见范围内的“历史版本”,这就是 MVCC。

MVCC 读什么数据?

  MVCC 具体查到什么数据,取决于事务隔离级别以及事务的 Read View。

  在最典型的 RR 隔离级别下,事务 B 的普通 SELECT不会读取事务 A 未提交的“已支付”,而是读取待支付,这实际上就是不读脏数据 + 普通读不阻塞写。

  以上操作在数据库简单抽象为流程链路,则:

  • 事务 A 为:UPDATE → 修改当前版本 → 产生 Undo Log → 持有 X 锁
  • 事务 B 为:普通 SELECT → 不去抢事务A的 X 锁 → 判断当前版本对自己是否可见 → 不可见 → 沿 Undo Log 找历史版本 → 得到“待支付”

  所以可以把 MVCC 很粗略地理解成:“你写你的,我读我在当前事务中应该看到的那个版本。”

读和写完全不冲突嘛?

  不能简单说:读和写完全不冲突,更准确的说法是:InnoDB 的普通一致性读,通过 MVCC 可以与数据写操作并发执行,从而避免普通 SELECT 因为行级 X 锁而等待。

  因为特殊的SELECT ... FOR UPDATE读操作是会冲突的。

  假设事务 A:

1
2
3
4
5
6
7
BEGIN;

UPDATE order
SET status = '已支付'
WHERE id = 1001;

-- 不提交

  此时事务 A 持有订单1001的 X Lock,事务 B 如果执行:

1
2
3
4
5
6
BEGIN;

SELECT *
FROM order
WHERE id = 1001
FOR UPDATE;

  这时候就不一样了,因为FOR UPDATE表达的是:我不是单纯看看数据,而是我要锁住它,准备修改。

  因此事务 B 需要获取X Lock,但是事务 A 已经持有 X Lock,所以此时会阻塞:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
事务A                         事务B

X Lock
│
│ SELECT ... FOR UPDATE
│ │
│ ↓
│ 请求 X Lock
│ │
│ X Lock 被 A 持有
│ │
│ 等待
│
↓
COMMIT
│
↓
释放 X Lock
│
↓
B获得 X Lock

  所以:普通 SELECT 可以通过 MVCC 不阻塞;但是SELECT ... FOR UPDATE 则可能因为锁而阻塞。

小结

  MySQL InnoDB 为了提高并发性能,并不是让所有读写操作都直接加锁,而是将“普通读”和“需要锁定数据的读写操作”区分开。普通 SELECT 通过 MVCC 读取事务可见的数据版本,因此通常不需要等待其他事务持有的行级 X 锁;而 UPDATE、DELETE 以及 SELECT … FOR UPDATE 等操作则通过行锁保证并发修改的一致性。

什么业务场景读写都必须加锁?

  在大多数业务场景中,可以按 MVCC 直接读取数据的快照,与此同时其他事务可以修改相同的数据,那么,什么情况下,不能这么做,必须保证读写互斥,读写都必须加锁?

  先上答案:如果业务只是“查看数据”,允许看到一个一致的历史快照即可,那么通常使用 MVCC;如果业务要求“我看到的数据必须是当前最新数据,并且在我做出业务决策之前,不能让其他事务修改它”,就不能只靠 MVCC,而需要加锁。

  简而言之,真正需要“读写互斥”的场景,核心不是“读操作比较重要”,而是这个读操作本身就是后续写操作的决策依据。

最典型的场景:扣库存

  假设商品 A 库存为 1,现在两个用户同时购买。

  事务 A 执行下面的 SQL 读到:stock = 1

1
2
3
4
5
BEGIN;

SELECT stock
FROM product
WHERE id = 1001;

  然后准备:

1
2
3
UPDATE product
SET stock = stock - 1
WHERE id = 1001;

  如果事务 A 只是普通 MVCC 读取 A 看到库存 = 1,与此同时事务 B 也看到库存 = 1,那么两个人都会认为“还有库存,我可以买。”

  这时候就出现问题了,因此库存这种场景,需要“当前读 + 锁”。

  所以,一般事务 A 会写成:

1
2
3
4
5
6
BEGIN;

SELECT stock
FROM product
WHERE id = 1001
FOR UPDATE;

  这里的 FOR UPDATE 非常关键,它不是“给我一个历史版本,而是“我要拿当前数据,并且我要锁住它,因为我接下来要根据这个数据做修改。”

  于是流程变为:库存 = 1 → 事务A → SELECT … FOR UPDATE → 获得X锁 → 库存 = 1 → 扣减 → 库存 = 0 → COMMIT

  此时事务 BSELECT ... FOR UPDATE就必须等待,因为 A 已经持有 X Lock,B 再去请求 X Lock 是需要等待的。

  这就是典型的读写互斥场景。

为什么有时候 MVCC 不能用?

  MVCC 的核心思想是:“你读你的版本,我改我的版本”,这对于查询订单、查询商品详情、查询用户信息、查询历史记录非常合适,但是库存扣减不是简单的“看看”,它实际上是:读取库存 → 判断库存是否足够 → 根据判断结果决定是否购买 → 修改库存

  换而言之,“读取”本身参与了业务决策。所以这里必须保证:我读取库存 → 我判断库存 → 我修改库存这一整个过程不能被其他事务插入修改,必须让这种流程原子性的排队顺序处理。

必须读写互斥的业务场景

  什么情况下需要加锁处理,可以重点关注下面这些场景。

场景 为什么不能只读 MVCC 快照
扣库存 读取库存后要决定能不能扣
账户扣款/提现 读取余额后决定能不能扣钱
优惠券/名额抢占 读取剩余数量后决定能否领取
状态机流转 读取当前状态后决定下一状态
座位/资源预订 读取资源是否可用后进行占用
并发任务抢占 读取任务状态后决定谁来处理
防止超卖 读取数量和后续扣减必须协调
库存/额度/配额控制 读取结果直接决定后续操作

  它们有一个共同特点:“读 → 判断 → 写”,这个“读 → 判断 → 写”过程不能被其他事务随意插入。

小结:一个非常好用的判断方法

  以后我们碰到一个业务场景,可以问自己一句:“我读取这个数据之后,是不是要根据这个数据决定接下来能不能做某件事?”

  如果只是:读取 → 展示给用户,通常 MVCC 就够了,例如:

  • 查询订单
  • 查询商品
  • 查询用户
  • 查询日志
  • 查询报表

  如果是读 → 判断 → 修改写,那么就要高度警惕这里不能简单依赖普通 MVCC 快照,例如:

1
2
3
4
5
查询库存 → 判断库存 → 扣库存
查询余额 → 判断余额 → 扣余额
查询优惠券数量 → 判断 → 领取
查询座位 → 判断空闲 → 占座
查询订单状态 → 判断待支付 → 修改状态

  这类场景就需要通过一些方案来保证并发正确性:

  • 悲观锁:SELECT ... FOR UPDATE
  • 乐观锁:UPDATE ... WHERE version = ?
  • 乐观锁原子 UPDATE:UPDATE ... SET stock = stock - 1 WHERE stock > 0

普通的 SELECT 会被锁阻塞嘛?

  对普通的 SELECT 操作而言,通常不加行锁,通常不阻塞写,那可能有人就有疑问:难道有意外情况?

  是的,不绝对。这里需要把 “普通 SELECT 不加行锁” 和 “普通 SELECT 完全不会被锁影响” 分开。

  在 InnoDB + 正常的 MVCC 一致性读 + 普通 SELECT 这个语境下,可以认为:普通 SELECT 本身不会对查询到的记录加 Record X/S 行锁,因此通常不会因为其他事务持有行级 X 锁而阻塞,也不会阻塞其他事务的 UPDATE。

  但是存在几个重要的“例外/边界”。

普通 SELECT 可能受 MDL 锁影响

  例如SELECT * FROM orders;它不会加行锁,但如果对表时进行结构变更,则涉及 MDL(元数据锁),此时:

  • 如果事务 A 正在做修改表结构的 DDLALTER TABLE orders ...且未执行完事务未提交,那么事务 B 执行的普通 SELECT 需要等待
  • 如果一个事务 A 正在执行普通 SELECT ,而另一个事务 B 需要执行 DDLALTER TABLE orders ...修改表结构,那么事务 B 需要等待事务 A 的提交

  所以普通 SELECT 在行数据层面:MVCC → 通常不等行锁,但是在表结构层面:MDL → 仍可能等待

普通 SELECT 也可能遇到其他非行锁问题

  例如:

  • 表锁
  • MDL 锁
  • MySQL 内部其他资源竞争
  • 查询本身等待 IO、CPU、磁盘等

  所以不能说“普通 SELECT 永远不会阻塞”,准确说法是:普通 SELECT 不会因为 InnoDB 行级 X 锁而等待其他事务提交。

普通 SELECT 受隔离级别SERIALIZABLE影响

  如果事务隔离级别是SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;,那么普通 SELECT 的行为就不再是我们通常讨论的 RR/RC 下那种纯粹的 MVCC 快照读。

  在 SERIALIZABLE 下,普通 SELECT 会采用更严格的锁定方式,从而可能与写操作发生阻塞。

  所以如果我们想说“普通 SELECT 不加行锁、不阻塞写。”,最好补充前提:在 InnoDB 的 READ COMMITTED / REPEATABLE READ 等正常 MVCC 一致性读场景下,普通 SELECT 是快照读,不会因为行级 X 锁而阻塞。

行锁锁住的是什么?

  InnoDB 的 Record Lock、Gap Lock、Next-Key Lock,本质上都是加在索引上的锁,而不是直接加在“数据行”这个抽象概念上的。

  尤其是 InnoDB 的行锁,实际上依赖索引,下面通过几个例子分别讨论下这块。

Record Lock 锁的到底是什么?

Record Lock 锁主键索引

  假设电商系统有一张订单表:

1
2
3
4
5
6
orders

id user_id status amount
10001 2001 待支付 99
10002 2002 已支付 199
10003 2001 待支付 299

  其中id是主键索引,user_id存在二级索引idx_user_id(user_id)

  现在用户 2001 支付订单 10001,那么业务代码可能会执行:

1
2
3
UPDATE orders
SET status = '已支付'
WHERE id = 10001;

  这里的业务含义不是简单的“查询订单”,而是修改订单:我要对订单 10001 做支付状态变更,在我处理期间,不允许其他事务同时修改这笔订单。

  因此,MySQL 根据主键索引找到对应的记录,之后进行修改:

1
2
3
4
id
10001 ← Record Lock
10002
10003

  所以这里的 Record Lock,本质上就是锁住聚簇索引中 id=10001 对应的索引记录。

  因此此时如果另一个用户,比如说运营人员发现这个商品订单价格上错了,想作废这个订单,那么另一个业务线程如果同时执行下面的 SQL,就会被这个 X 锁阻塞:

1
2
3
UPDATE orders
SET status = '作废'
WHERE id = 10001;

  这里从业务角度可以理解成:订单 10001 → 正在支付 → 当前事务锁住这条订单 → 其他事务暂时不能修改这条订单

  虽然我们平时习惯说“锁住订单这一行”,但从 InnoDB 底层实现角度,更准确的说法是:锁住了聚簇索引中这条订单对应的索引记录。

Record Lock 锁二级索引

  订单系统中更常见的是根据一些业务字段查询订单,还是前面的例子,我们知道user_id列上有普通二级索引idx_user_id,数据和之前相同:

1
2
3
4
5
6
orders

id user_id status
10001 2001 待支付
10002 2002 已支付
10003 2001 待支付

  如果订单通过二级索引操作呢?此时 Record Lock 锁什么?

  来看个例子:假设用户 2001 进入“我的订单”,业务需要对他的订单进行支付处理:

1
2
3
UPDATE orders
SET status = 已支付
WHERE user_id = 2001

  此时 MySQL 内部做了什么呢?

  答案是这样的,首先利用的是二级索引idx_user_id,通过其回表,找到具体存储了数据内容的聚簇索引,最后执行修改逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
user_id
2001 → id=10001
2001 → id=10003
2002 → id=10002

逻辑图说明

二级索引
idx_user_id
│
┌──────────┴──────────┐
↓ ↓
user_id=2001 user_id=2001
id=10001 id=10003
│ │
↓ ↓
聚簇索引 聚簇索引
id=10001 id=10003
│ │
↓ ↓
完整订单 完整订单

  上面的流程就是:user_id=2001 → 订单 id=10001、10003 → 再回到聚簇索引 → 找到完整订单数据。

  在这个例子中:不能简单理解成锁的是user_id=2001这行数据,更准确地说,是在索引访问过程中,先锁定涉及的对应的二级索引记录,之后对于涉及这些订单在聚簇索引中的记录,也将其被锁定,最终,其实在两种索引上都加了锁。

  所以,在实际订单业务里:

  • 如果 WHERE id = ? 走主键,那么主要就是在主键聚簇索引上定位订单,此时锁主键索引
  • 如果 WHERE user_id = ? 走二级索引,那么会先通过二级索引定位订单,再涉及对应的聚簇索引记录,此时先锁二级索引,再锁主键索引
  • 如果订单表还有idx_order_no(order_no),遇到UPDATE ...WHERE order_no = 'XXX',也会先锁这个二级索引,再锁主键索引

  从上可知,InnoDB 的行锁是跟索引访问路径密切相关的。

  因此,我们可以记住:行锁最终是围绕索引记录实现的;如果走二级索引进行锁定操作,通常既涉及二级索引记录,也会锁定对应的聚簇索引记录。

Gap Lock 锁的到底什么?

  如果只有 Record Lock,会存在一个非常严重的问题:只能防止别人修改已有记录,却防止不了别人插入新的记录。

  看一个新的业务,假设存在一张商品库存表,数据如下:

1
2
3
4
id    stock
10 100
20 100
30 100

  现在事务 A:

1
2
3
4
SELECT *
FROM product
WHERE id BETWEEN 10 AND 20
FOR UPDATE;

如果只锁:

1
2
10 ← Record Lock
20 ← Record Lock

  那么事务 B 仍然可以INSERT INTO product(id) VALUES (15);

1
2
3
4
10        15        20
●---------○---------●
↑ ↑
锁住 锁住

  因为:15 原本根本不存在,你没法给一个“还不存在的记录”加 Record Lock。

  所以 InnoDB 引入了 Gap Lock:Gap Lock 锁的不是某条记录,而是索引记录之间的“间隙”。

  例如下面表示禁止其他事务在这个范围内插入新的索引记录:

1
2
3
10 -------- 20
↑
Gap Lock

  所以事务 A 除了锁住已有记录 10、20,还需要保护10 ~ 20这个范围。

  于是事务 B INSERT INTO product(id) VALUES (15);就会被阻塞。

Next-Key Lock 到底锁什么?

  因为实际业务中我们经常需要同时解决两个问题:

  • 不能修改已有记录
  • 不能插入范围内的新记录

  所以:Next-Key Lock = Record Lock + Gap Lock

  例如索引:

1
2
10      20      30
●-------●-------●

  假设锁住 20 的 Next-Key Lock,它保护的是(10, 20],也就是:

1
2
10 -------- 20
Gap Record

  所以组合起来就是Next-Key Lock:

1
2
3
Gap Lock     Record Lock
↓ ↓
10 ------------ 20

三者对比

  为什么 MySQL 要将行锁设计得这么复杂?

  实际上是因为数据库需要同时解决两类并发问题,如下图:

锁 真实业务中解决什么问题
Record Lock 多个人同时操作同一条已经存在的业务数据,防止别人修改/删除已经存在的记录
Gap Lock 多个人同时操作某个业务范围,防止别人往这个不存在的范围里新增数据
Next-Key Lock 多个人同时操作时,既要防止别人修改已有数据,又要防止别人往这个业务范围新增数据

  这三个东西其实是在解决不同层次的并发问题。

参考

文章信息

时间 说明
2022-02-18 初稿
0%